[JEWEL-921] Migrate .composed Calls to Modifier.Node API#3423
[JEWEL-921] Migrate .composed Calls to Modifier.Node API#3423DanielSouzaBertoldi wants to merge 1 commit intoJetBrains:masterfrom
Conversation
|
|
||
| Modifier.modifierLocalProvider(ModifierLocalActivated) { parentActivated } | ||
| override fun update(node: TrackActivationNode) { | ||
| // no-op |
There was a problem hiding this comment.
Detekt do be detekting
| private class ActivatedModifierLocal : ModifierLocalProvider<Boolean>, ModifierLocalConsumer { | ||
| private var parentActivated: Boolean by mutableStateOf(false) | ||
| private class TrackActivationNode : | ||
| Modifier.Node(), FocusEventModifierNode, ModifierLocalModifierNode, ObserverModifierNode { |
There was a problem hiding this comment.
We have to use both ModifierLocalModifierNode and ObserverModifierNode to provide and consume local modifiers + get notified of changes in said modifiers.
It's basically the equivalent of onModifierLocalsUpdated previously used.
You can find a sample of ObserverModifier Node here
| override val shouldAutoInvalidate: Boolean = false | ||
| override val isImportantForBounds = false |
There was a problem hiding this comment.
I stole this from Compose Foundation's Border impl 😛 since we are invalidating our border modifier manually, we can safely avoid unnecessary invalidations
The importantForBounds is basically if talkback should highlight the component taking into account the set border (AFAIK). It's a useless info semantics-wise in the case of borders
You can check it here
| private val pointerInputNode = | ||
| delegate( | ||
| SuspendingPointerInputModifierNode { | ||
| val scroller = TrackPressScroller(coroutineScope, sliderAdapter, reverseLayout, clickBehavior) | ||
|
|
||
| detectScrollViaTrackGestures(isVertical = isVertical, scroller = scroller) | ||
| } | ||
| ) |
There was a problem hiding this comment.
We store the delegate in a property (instead of using an init block) because we need to reference it to call resetPointerInputHandler()
The TrackPressScroller is instantiated only once inside the suspending block, capturing the initial state of the parameters. If those parameters change, the scroller becomes stale, so we must manually reset the handler to restart the block and recreate the scroller
| } | ||
|
|
||
| override fun onFocusEvent(focusState: FocusState) { | ||
| if (isFocused != focusState.isFocused) { |
There was a problem hiding this comment.
Just to clarify - are focusState.isFocused and focusState.hasFocus equivalent in this context?
My initial assumption was that a focusGroup node can never be focused itself, only its children can. This would mean focusState.isFocused is always false on TrackActivationNode, making updateProvidedValue() always compute parentActivated && false = false and silently breaking the activation chain.
To verify, I put together a minimal reproducer simulating trackWindowActivation providing true from above, with onActivated on a child layout node:
private data object SimulatedWindowActivationElement : ModifierNodeElement<SimulatedWindowActivationNode>() {
override fun create() = SimulatedWindowActivationNode()
override fun update(node: SimulatedWindowActivationNode) = Unit
override fun InspectorInfo.inspectableProperties() { name = "simulatedWindowActivation" }
}
private class SimulatedWindowActivationNode : Modifier.Node(), ModifierLocalModifierNode {
override val providedValues = modifierLocalMapOf(ModifierLocalActivated to true)
}
fun main(): Unit = application {
Window(onCloseRequest = ::exitApplication) {
IntUiTheme(theme = JewelTheme.lightThemeDefinition(), styling = ComponentStyling.default()) {
var activationReceived by remember { mutableStateOf(false) }
Column(Modifier.then(SimulatedWindowActivationElement).trackActivation()) {
Text(
"Activation received: $activationReceived",
modifier = Modifier.onActivated { activationReceived = it },
)
OutlinedButton(onClick = {}) { Text("Click to focus") }
}
}
}
}I also added logging inside TrackActivationNode.onFocusEvent and updateProvidedValue:
override fun onFocusEvent(focusState: FocusState) {
println("onFocusEvent: isFocused=${focusState.isFocused} hasFocus=${focusState.hasFocus}")
println("node.isAttached=$isAttached")
println("focusState=$focusState")
println("focusState::class=${focusState::class}")
if (isFocused != focusState.isFocused) {
isFocused = focusState.isFocused
updateProvidedValue()
}
}
private fun updateProvidedValue() {
println("updateProvidedValue: parentActivated=$parentActivated isFocused=$isFocused result=${parentActivated && isFocused}")
provide(ModifierLocalActivated, parentActivated && isFocused)
}Which produced the following output when clicking the button:
updateProvidedValue: parentActivated=true isFocused=false result=false
onFocusEvent: isFocused=false hasFocus=false
node.isAttached=true
focusState=Inactive
focusState::class=class androidx.compose.ui.focus.FocusStateImpl
onFocusEvent: isFocused=true hasFocus=true
node.isAttached=true
focusState=Active
focusState::class=class androidx.compose.ui.focus.FocusStateImpl
updateProvidedValue: parentActivated=true isFocused=true result=true
activationReceived correctly transitions to true, and isFocused and hasFocus move together. The TrackActivationNode receives FocusStateImpl.Active - not ActiveParent - when a child gains focus, which suggests onFocusEvent delivers the bubbled-up state from the event source rather than reflecting the layout node's own focus state in the tree hierarchy.
Is that the right understanding? If so, focusState.isFocused is correct here and the two properties are equivalent in this context.
There was a problem hiding this comment.
tl;dr but isFocused == this node is the one owning the focus, hasFocus == this node or any of its children have the focus
There was a problem hiding this comment.
In this case we could use focusState.isFocused or focusState.hasFocus interchangeably 😄
Given the nature of the code (active if the parent or children have focus), hasFocus is more semantically aligned with the intent, though.
There was a problem hiding this comment.
Mine understanding was that the node focusGroup is attached to can't itself be focused but only it's children. That why I though focusState.isFocus would not be true, and focusState.hasFocus is the API we had to use here :)
Thanks for explaining this!
@DanielSouzaBertoldi I agree with you that usage of hasFocus is more semantically aligned and it's helps reader understand the code easier. 👍
There was a problem hiding this comment.
Shouldn't this be using hasFocus instead of isFocused, as discussed? Or was there a reason why it wasn't switched?
There was a problem hiding this comment.
Thanks for migrating these! This is a nice cleanup and performance improvement, but we never had the time to do it.
There are a few minor nits and a KDoc copy-paste mistake. Also, this PR should include a release notes section since the performance gains and the new @Stable annotations are user-facing improvements for developers using Jewel.
Lastly, the isFocused vs hasFocus switch we agreed on in the comments hasn't been applied yet. Can I insist on that being done, or was there a reason why it wasn't?
| * A callback modifier that triggers whenever the activation state changes. | ||
| * | ||
| * This modifier consumes the value provided by [ModifierLocalActivated] (set by [trackWindowActivation], | ||
| * [trackActivation] or [trackWindowActivation]). When that value changes, the [onChanged] lambda is invoked. |
There was a problem hiding this comment.
Copy-paste mistake 👯 trackWindowActivation is listed twice.
| * [trackActivation] or [trackWindowActivation]). When that value changes, the [onChanged] lambda is invoked. | |
| * [trackComponentActivation] or [trackActivation]). When that value changes, the [onChanged] lambda is invoked. |
| * @param enabled Whether this callback is active. If `false`, the modifier is effectively a no-op. | ||
| * @param onChanged A lambda called with the new activation state (`true` for active, `false` for inactive). | ||
| */ | ||
| public fun Modifier.onActivated(enabled: Boolean = true, onChanged: (Boolean) -> Unit): Modifier = |
There was a problem hiding this comment.
Nit: Could we add the @Stable annotation here? The other public modifier functions in this file gained it in this PR, and it seems correct to add it here as well for consistency.
| private class TrackComponentActivationNode(var awtParent: Component) : Modifier.Node(), ModifierLocalModifierNode { | ||
| override val providedValues = modifierLocalMapOf(ModifierLocalActivated to false) | ||
|
|
||
| val listener = |
There was a problem hiding this comment.
Nit: Should this be private val? Every other node uses a private listener.
| override val shouldAutoInvalidate: Boolean = false | ||
| override val isImportantForBounds = false | ||
|
|
||
| val borderCache = BorderCache() |
There was a problem hiding this comment.
Nit: I'd rather this be private val to keep it properly encapsulated inside the node.
|
|
||
| override fun InspectorInfo.inspectableProperties() { | ||
| name = "scrollbarDrag" | ||
| properties["interactinSource"] = interactionSource |
There was a problem hiding this comment.
Nit: typo
| properties["interactinSource"] = interactionSource | |
| properties["interactionSource"] = interactionSource |
| } | ||
|
|
||
| override fun onFocusEvent(focusState: FocusState) { | ||
| if (isFocused != focusState.isFocused) { |
There was a problem hiding this comment.
Shouldn't this be using hasFocus instead of isFocused, as discussed? Or was there a reason why it wasn't switched?
Context
About three years ago the Compose team created the
Modifier.NodeAPI to circumvent the performance issues thatcomposed {}had. This PR migrates (or better yet, upgrades) our custom modifiers to use this new APIChanges
Activation,Border,ScrollbarandSlider.Evidences
Activation
yeah.mp4
yeah2.mp4
Border
yeah3.mp4
Scrollbar
Screen.Recording.2026-02-13.at.10.04.43.mov
Slider
Screen.Recording.2026-02-13.at.10.06.19.mov